歡迎來到 Day 28!昨天我們成功建立了 React 評測引擎(react-executor.ts),並通
過 POC 驗證了方案的可行性。今天,我們要將這個強大的引擎正式整合到我們的 AI 面試官系統中,讓它能夠真正運作起來!
不過實際上,這個功能我會暫時放到一個獨立的分支處理,畢竟就像我昨天說的,目前我們雖然可以透過 POC 實踐的方案去完成 React 程式碼的驗證,但不論是安全性、比對的準確性都有著極大的優化空間,作為部署環境的功能還不夠格,最終我們的成品並不會包含這個實作功能,但我還是希望先透過一些最小化實作讓你了解實際上裡面的概念大致上是如何,並提供你工具和方向,有興趣的朋友可以透過我文章中工具的關鍵字與 AI 協作研究,這樣要完成足以上線的版本應該不至於太過於困難。
雖然這個東西是不上線,但該做的還是得做完,不然誰能證明我們的方案真的可行呢?讓我們開始吧!
✅ 整合 API:修改評測 API 以支援 React 題目
✅ 新增題目:建立第一道 React 實作題
✅ 優化 Prompt:讓 AI 能針對 React 測試結果提供有價值的回饋
✅ 測試驗證:確保整個評測流程正常運作
現在我們要修改 app/api/interview/evaluate/route.ts
,加入對 React 題目的支援。核心思路是:當偵測到題目類型為 React 程式題時,調用我們的 react-executor 而非 Judge0。
打開 app/api/interview/evaluate/route.ts
,進行以下修改:
// app/api/interview/evaluate/route.ts
import { NextResponse } from 'next/server';
import questionsData from '@/data/questions.json';
import { formatChatHistory } from '@/app/lib/utils';
import { buildUnifiedPrompt } from '@/app/lib/prompt';
import { performRagSearch } from '@/app/lib/supabase/server';
import { generateEmbedding, generateContentStream } from '@/app/lib/gemini';
import { getFormattedJudge0Result } from '@/app/lib/judge0';
import {
createAuthClient,
supabase as adminSupabase,
} from '@/app/lib/supabase/server';
import {
evaluateReactComponent,
ReactTestCase,
TestCaseResult,
} from '@/app/lib/react-executor';
import { Question } from '@/app/types/question';
const questions = questionsData as Question[];
function formatReactEvaluationResults(
results: TestCaseResult[],
error?: string
): string {
if (error) {
return `❌ 評測過程發生錯誤:${error}\n\n請檢查你的程式碼是否有語法錯誤或其他問題。`;
}
const passedCount = results.filter((r) => r.passed).length;
const totalCount = results.length;
let output = `## 測試結果總覽\n通過: ${passedCount}/${totalCount}\n\n`;
results.forEach((result, index) => {
output += `### 測試案例 ${index + 1}: ${result.name}\n`;
if (result.passed) {
output += `✅ **通過**\n`;
output += `渲染結果符合預期\n\n`;
} else {
output += `❌ **失敗**\n`;
if (result.missing && result.missing.length > 0) {
output += `缺少以下預期內容:\n`;
result.missing.forEach((pattern) => {
output += ` - "${pattern}"\n`;
});
}
output += `\n實際渲染的 HTML:\n\`\`\`html\n${result.actual}\n\`\`\`\n\n`;
}
});
return output;
}
/**
* 準備評估所需的上下文資料
*/
async function prepareEvaluationContext(
question: Question,
userAnswer: string
) {
let judge0Result = 'not applicable for this question'; // 一般程式題結果
let reactTestResult = 'not applicable for this question'; // React 測試結果(新增)
let ragContext = 'not applicable for this question';
// ========================================
// React 程式題:使用我們的原生評測引擎
// ========================================
if (question.topic === 'React' && question.type === 'code') {
console.log('🎯 偵測到 React 程式題,使用原生評測引擎');
const testCases: ReactTestCase[] = question.testCases || [];
const evaluation = await evaluateReactComponent(userAnswer, testCases);
reactTestResult = formatReactEvaluationResults(
evaluation.results,
evaluation.error
);
console.log('✅ React 評測完成');
}
// ========================================
// 一般程式題:使用 Judge0
// ========================================
else if (question.type === 'code') {
console.log('📝 偵測到一般程式題,使用 Judge0');
judge0Result = await getFormattedJudge0Result(userAnswer);
}
// ========================================
// 概念題:使用 RAG
// ========================================
if (question.type === 'concept') {
console.log('💡 偵測到概念題,執行 RAG 搜尋');
const answerEmbedding = await generateEmbedding(userAnswer);
ragContext = await performRagSearch(answerEmbedding, question.id);
}
return { ragContext, judge0Result, reactTestResult }; // 回傳三個欄位
}
export async function POST(request: Request) {
try {
// 1. 驗證使用者身分
const supabase = await createAuthClient();
const {
data: { user },
} = await supabase.auth.getUser();
if (!user) {
return new Response('Unauthorized', { status: 401 });
}
// 2. 取得 isFollowUp 旗標
const { questionId, answer, history, isFollowUp } = await request.json();
const question = questions.find((q) => q.id === questionId);
if (!question) {
return NextResponse.json(
{ error: 'Question not found' },
{ status: 404 }
);
}
// 準備所有需要的上下文變數
const formattedHistory = formatChatHistory(history);
const { ragContext, judge0Result, reactTestResult } =
await prepareEvaluationContext(question, answer);
// 填充統一的 Prompt 模板
const finalPrompt = buildUnifiedPrompt({
isFollowUp,
formattedHistory,
question: question.question,
ragContext,
judge0Result: judge0Result,
userAnswer: answer,
reactTestResult: reactTestResult,
});
if (!finalPrompt) {
return NextResponse.json(
{ error: 'Invalid question type' },
{ status: 400 }
);
}
const stream = await generateContentStream(
finalPrompt,
async (fullJson) => {
// 這個函式會在 gemini.ts 中被呼叫
// 只有在不是追問的情況下,才執行資料庫寫入
if (!isFollowUp) {
try {
const finalEvaluation = JSON.parse(fullJson);
const recordToInsert = {
user_id: user.id,
question_id: questionId,
user_answer: answer,
evaluation: finalEvaluation,
score: finalEvaluation.score,
};
const { error: insertError } = await adminSupabase
.from('practice_records')
.insert(recordToInsert);
if (insertError) {
console.error('Error in onComplete DB write:', insertError);
}
} catch (e) {
console.error('Failed to parse or insert record in onComplete:', e);
}
}
}
);
return new Response(stream, {
headers: { 'Content-Type': 'application/json; charset=utf-8' },
});
} catch (error) {
console.error('Error in evaluation API:', error);
if (error instanceof Error) {
return NextResponse.json({ error: error.message }, { status: 500 });
}
return NextResponse.json(
{ error: 'Internal Server Error' },
{ status: 500 }
);
}
}
import {
evaluateReactComponent,
ReactTestCase,
TestCaseResult,
} from '@/app/lib/react-executor';
新增結果格式化函式formatReactEvaluationResults
: 將測試結果轉換為 Markdown 格式
包含通過/失敗統計、缺失內容、實際渲染的 HTML
新增問題評估函數prepareEvaluationContext
將我們之前判斷題目的邏輯進行整合,並加入了 React 問題的判斷,如下方的邏輯:
if (question.topic === 'React' && question.type === 'code') {
// 使用 React 原生評測
} else if (question.type === 'code') {
// 使用 Judge0
}
現在讓我們在題庫中新增一道 React 實作題。打開 data/questions.json
,在陣列中加入以下題目:
{
"id": "react-pro-001", // 由於是手動產生題目,暫時我們先用回我們之前設定的id結構,日後要像之前的文章做批量產生時可以繼續使用UUID的格式
"topic": "React",
"type": "code",
"difficulty": "easy",
"question": "請建立一個名為 `Counter` 的 React 元件。\n\n**需求:**\n- 接收一個 `initialCount` prop(預設值為 0)\n- 使用 `useState` 管理計數狀態\n- 顯示當前計數值\n- 包含「增加」和「減少」兩個按鈕\n\n**注意:** 我們只會驗證初始渲染的 HTML 結構,不會測試按鈕的實際點擊行為。",
"hints": [
"使用 `useState` hook 來管理計數器的狀態",
"記得為 `initialCount` prop 設定預設值",
"確保 JSX 中包含計數值的顯示元素和兩個按鈕"
],
"keyPoints": [
"正確使用 useState hook 並傳入 initialCount 作為初始值",
"使用 JSX 語法建立 UI 結構",
"正確顯示當前計數值",
"包含文字為「增加」和「減少」的按鈕元素"
],
"starterCode": "import React, { useState } from 'react';\n\nfunction Counter({ initialCount = 0 }) {\n const [count, setCount] = useState(initialCount);\n \n return (\n <div>\n {/* TODO: 在此處實作你的 UI */}\n </div>\n );\n}\n\nexport default Counter;",
"testCases": [
{
"name": "預設初始值 (0)",
"props": {},
"expectedPatterns": ["<div", "0", "增加", "減少", "<button"]
},
{
"name": "初始值為 5",
"props": { "initialCount": 5 },
"expectedPatterns": ["5", "增加", "減少"]
},
{
"name": "初始值為負數 (-3)",
"props": { "initialCount": -3 },
"expectedPatterns": ["-3", "增加", "減少"]
}
]
}
testCases
結構
驗證策略
為了讓 Gemini 能更好地解讀 React 的測試結果,我們需要在 Prompt 中加入針對性的指示。
打開 app/lib/prompts.ts
,找到 unifiedPromptTemplate
的變數,加入以下邏輯:
**Special Guidelines for React Component Evaluation:**
When evaluating React components, consider:
1. **Functional Correctness**: Does the component render the expected output? Check if all test cases passed.
2. **React Best Practices**:
- Is \`useState\` used correctly?
- Are props handled properly with default values?
- Is the JSX structure clean and semantic?
3. **Code Quality**:
- Is the component logic clear and maintainable?
- Are there any potential bugs or anti-patterns?
4. If tests failed, clearly explain:
- Which test cases failed
- What patterns were missing in the rendered HTML
- Specific suggestions for fixing the issues
同時下方的型別與buildUnifiedPrompt
函數都需要修改,加入 React Result的判斷:
interface PromptContext {
isFollowUp: boolean;
formattedHistory: string;
question: string;
ragContext: string;
judge0Result: string;
reactTestResult: string; // 新增:React 測試結果
userAnswer: string;
}
export function buildUnifiedPrompt(context: PromptContext): string {
return unifiedPromptTemplate
.replace(/\${isFollowUp}/g, String(context.isFollowUp))
.replace(/\${formattedHistory}/g, context.formattedHistory)
.replace(/\${question}/g, context.question)
.replace(/\${ragContext}/g, context.ragContext)
.replace(/\${judge0Result}/g, context.judge0Result)
.replace(/\${reactTestResult}/g, context.reactTestResult) // 新增
.replace(/\${userAnswer}/g, context.userAnswer);
}
區分題目類型
透過關鍵字判斷是 React 測試還是一般程式測試
針對不同類型提供不同的評估指引
React 專屬指引
現在所有的程式碼都已就位,讓我們進行完整的測試!
首先自然是啟動開發我們的伺服器
npm run dev
前往 http://localhost:3000
使用你的帳號登入後進入Dashboard後,選擇程式實作並點選 React 題目,在面試介面中,應該能看到新增的 React 題目。
import React, { useState } from 'react';
function Counter({ initialCount = 0 }) {
const [count, setCount] = useState(initialCount);
return (
<div className="counter">
<p>計數: {count}</p>
<button onClick={() => setCount(count + 1)}>增加</button>
<button onClick={() => setCount(count - 1)}>減少</button>
</div>
);
}
export default Counter;
預期結果:
✅ 所有 3 個測試案例都通過
✅ Gemini 給予正面評價
✅ 可能會指出程式碼的優點
嘗試提交一個有問題的版本(例如少了「減少」按鈕):
import React, { useState } from 'react';
function Counter({ initialCount = 0 }) {
const [count, setCount] = useState(initialCount);
return (
<div>
<p>{count}</p>
<button>增加</button>
</div>
);
}
export default Counter;
預期結果:
❌ 部分測試案例失敗(缺少「減少」按鈕)
❌ Gemini 指出缺少的元素
✅ 提供具體的修正建議
在你的終端機中,應該能看到類似以下的 log:
============================================================
📋 開始評測
題目類型: React - code
題目 ID: react-code-001
============================================================
🎯 偵測到 React 程式題,使用原生評測引擎
✅ React 評測完成
恭喜你!今天我們完成了 React 評測功能的最後一哩路。讓我們回顧一下完成了什麼:
✅ API 整合:成功將 react-executor 引擎接入評測 API
✅ 題目建立:新增了第一道 React 實作題,包含完整的測試案例
✅ Prompt 優化:讓 Gemini 能理解並評估 React 測試結果
✅ 前端整合測試:驗證了整個評測流程的正確性
我們的 AI 面試官系統已經功能完整了!明天(Day 29)我們將進入收尾階段: